import pandas as pd
import plotly.graph_objects as go
import statistics as stats
import numpy as np
import os
import re
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.neighbors import KNeighborsClassifier
from sklearn import svm, preprocessing, metrics
from sklearn.linear_model import LogisticRegression
from sklearn.naive_bayes import GaussianNB
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
import matplotlib.pyplot as plt
In diesem Notebook geht es darum, Autoren aufgrund sprachlicher Merkmale ihrer Texte korrekt zu identifizieren.
Das basedir gibt den Pfad zum Verzeichnis an, in dem sich folgendes befinden sollte:
meta.csv mit den Metadaten der von AO3 gesammelten Textemeasures.csv mit den Maßen, die im letzten Notebook berechnet wurdentagged-stanza mit den annotierten Einzeltexten (die Dateien sind nach den IDs der Texte benannt)All diese Dateien befinden sich auf StudOn im Archiv ao3_authorship.7z.
basedir solltet ihr auf euren eigenen Rechnern natürlich anpassen, damit die Dateien auch eingelesen werden können.
basedir = r'ao3_authorship/'
meta = pd.read_csv(basedir + r'meta.csv')
measures = pd.read_table(basedir + 'measures.tsv', quoting=3) # aus stilometrie2.ipynb
measures = meta.merge(measures, on='id')
Wir werfen wieder die Kollaborationen raus (weil diese Labels sonst jeweils nur einmal vorhanden wären – damit ließe sich nicht viel anfangen):
measures = measures[~measures.author.isin(['Alma_Anor,merripestin', 'Argus_Persa,Bibibabubi,emma_screams,Kidhuzural,Ulan', 'emma_screams,Ulan'])]
measures.head()
| id | url | author | title | rating | archive_warnings | categories | fandoms | relationships | characters | ... | hits | summary | notes | STTR_0250 | STTR_0500 | STTR_0750 | STTR_1000 | MTLD | Lexical_density | Avg_sentence_length | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1006420 | https://archiveofourown.org/works/1006420?view... | Ablissa | A Different Sort of Adventure | Teen And Up Audiences | No Archive Warnings Apply | F/M | Doctor Who,Doctor Who (2005),Doctor Who & Rela... | Tenth Doctor/Rose Tyler,Ninth Doctor/Rose Tyler | Tenth Doctor,Rose Tyler,Ninth Doctor | ... | 6124 | <p>"She was a breath of fresh air in his life,... | <p>\n (See the end of the chapter for... | 0.609671 | 0.512356 | 0.458091 | 0.421514 | 103.358394 | 0.297362 | 12.0 |
| 1 | 1074849 | https://archiveofourown.org/works/1074849?view... | Ablissa | Is She Happy? | General Audiences | No Archive Warnings Apply | F/M | Doctor Who,Doctor Who (2005),Doctor Who & Rela... | Tenth Doctor/Rose Tyler,Eleventh Doctor/Rose T... | Tenth Doctor,Eleventh Doctor,Rose Tyler,Rose T... | ... | 4470 | <p>On his darkest day, the Doctor meets a fami... | NaN | 0.577778 | 0.497000 | 0.432889 | 0.407500 | 73.042222 | 0.305998 | 8.0 |
| 2 | 5510432 | https://archiveofourown.org/works/5510432?view... | Ablissa | First Impressions (Perhaps I Was Wrong) | Mature | No Archive Warnings Apply | M/M | Phandom/The Fantastic Foursome (YouTube RPF) | Dan Howell/Phil Lester,Dan Howell & Phil Leste... | Dan Howell,Phil Lester,Chris Kendall,PJ Liguor... | ... | 84173 | <p>Phil Lester goes back to university for his... | <p>\n (See the end of the chapter for... | 0.630932 | 0.536614 | 0.484333 | 0.447561 | 122.527927 | 0.338135 | 14.0 |
| 3 | 5747662 | https://archiveofourown.org/works/5747662?view... | Ablissa | Secrets We Didn't Need To Keep | Teen And Up Audiences | No Archive Warnings Apply | M/M | Phandom/The Fantastic Foursome (YouTube RPF) | Dan Howell/Phil Lester,Dan Howell & Phil Lester | Dan Howell,Phil Lester,Louise Pentland Watson | ... | 22186 | <p>Dan Howell. Twenty-four. In love with his b... | <p>Hi! This is just a Phan one-shot I wrote a ... | 0.625556 | 0.532000 | 0.484444 | 0.447778 | 121.752209 | 0.340645 | 13.0 |
| 4 | 6366910 | https://archiveofourown.org/works/6366910?view... | Ablissa | It's Just A Formality | Teen And Up Audiences | No Archive Warnings Apply | M/M | Phandom/The Fantastic Foursome (YouTube RPF) | Dan Howell/Phil Lester,Dan Howell & Phil Lester | Dan Howell,Phil Lester | ... | 10023 | <p>"Well, I guess we've never really told you,... | NaN | 0.633538 | 0.540333 | 0.487333 | 0.454333 | 119.235779 | 0.329418 | 12.0 |
5 rows × 30 columns
Um mit Scikit-learn etwas klassifizieren können, müssen wir zuerst definieren, was abhängige Variable (oder Zielvariable) und was unabhängige Variablen (oder Prädiktoren) sein sollen. Die abhängige Variable (Name des Autors) nennen wir y, die Menge der unabhängigen Variablen X.
Als unabhängige Variablen nehmen wir alle Spalten von STTR_0250 bis einschl. Avg_sentence_length – ihr könnt hier aber gerne mit verschiedenen Teilmengen ausprobieren, wie sich die Modellqualität abhängig von der Anzahl und der Auswahl der Variablen verändert.
y = measures['author']
X = measures.loc[:, 'STTR_0250':'Avg_sentence_length']
Im nächsten Schritt standardisieren wir die Prädiktoren, sodass sie vergleichbar werden. Dazu setzen wir für jeden Prädiktor den Mittelwert auf 0 und die Standardabweichung auf 1 (wie in einer Standardnormalverteilung). Mathematisch ist das sehr einfach: Von jedem Einzelwert wird das arithmetische Mittel abgezogen, das Ergebnis wird dann durch die Standardabweichung geteilt.
Als Beispiel:
(measures['Avg_sentence_length'] - measures['Avg_sentence_length'].mean()) / measures['Avg_sentence_length'].std()
0 -0.657505
1 -1.360084
2 -0.306216
3 -0.481861
4 -0.657505
...
187 -0.833150
188 1.625874
189 2.328452
190 1.450229
191 0.747651
Name: Avg_sentence_length, Length: 189, dtype: float64
Die einzelnen Werte, die sich daraus ergeben, nennt man auch z-Werte. Ein z-Wert gibt an, wie viele Standardabweichungen der ursprüngliche Wert vom arithmetischen Mittel der Verteilung entfernt ist.
Scikit-learn stellt hierfür als Abkürzung die Funktion scale() bereit:
X_scaled = preprocessing.scale(X) # alle Prädiktoren standardisieren
X_scaled
array([[-0.2970513 , -0.64225821, -0.77193066, ..., -0.29457692,
-2.48689186, -0.65925175],
[-1.78354114, -1.28902413, -1.78784536, ..., -1.99129003,
-2.11529544, -1.36369603],
[ 0.69387807, 0.37942686, 0.28588944, ..., 0.7782893 ,
-0.73243593, -0.30702961],
...,
[ 0.52479275, 0.81303066, 1.13240322, ..., -0.05786675,
1.87086521, 2.33463642],
[-0.85797237, -0.46198618, -0.25349915, ..., -1.00792158,
1.60269392, 1.45408107],
[-0.0856054 , 0.26307469, 0.50187981, ..., -0.38359841,
1.27928788, 0.7496368 ]])
(In der letzten Zeile sieht man die Werte für Avg_sentence_length. Die kleinen Abweichungen im Vergleich zu unserer eigenen Berechnung ergeben sich daraus, dass Scikit-learn hier – aus welchem Grund auch immer – die unkorrigierte Standardabweichung verwendet. Das sollte letztlich aber irrelevant sein.
In späteren Schritten teilen wir X und y auf. Es ist dann empfehlenswert, X nicht schon vorab zu standardisieren, sondern mit Pipelines und StandardScaler() zu arbeiten.)
Wir können nun direkt ein Modell mit unseren y- und X-Werten trainieren. Wir starten mit einer linearen Support Vector Machine (SVM). Um zu sehen, wie gut das Modell ist, überprüfen wir, wie gut Vorhersagen und tatsächliche Werte übereinstimmen (es gibt aber noch andere Maße für die Modellgüte).
mod = svm.SVC(kernel='linear') # siehe auch: https://scikit-learn.org/stable/modules/svm.html
mod.fit(X_scaled, y)
y_pred = mod.predict(X_scaled)
mod.score(X_scaled, y) # ebenso: metrics.accuracy_score(y, y_pred)
0.6243386243386243
62% der Autoren korrekt vorhersagt – gar nicht schlecht für den Anfang! Problematisch ist aber, dass wir unser Modell an denselben Daten getestet haben, mit denen wir es auch trainiert haben. Das heißt, es kann gut sein, dass sich unser Modell zu stark an die konkreten Daten angepasst hat (Überanpassung, overfitting) und bei anderen Daten wesentlich schlechtere Vorhersagen liefern würde (sich also nicht gut generalisieren lässt).
Deshalb ist es grundsätzlich sinnvoll, die Daten in Trainings- und Testdaten aufzuteilen. Mit den Trainingsdaten wird das Modell gefüttert, vorausgesagt werden dann die y-Werte der Testdaten.
Scikit-learn stellt dafür netterweise die Funktion train_test_split() bereit, mit der man seine Daten zufällig in Test- und Trainingsdaten aufteilen kann. Dabei kann man auch den Anteil der Testdaten festlegen (test_size) und ob nach bestimmten Variablenausprägungen stratifiziert werden soll (stratify) – in unserem Fall bietet es sich z.B. an, dass die einzelnen Autoren in Test- und Trainingsdaten prozentual immer ungefähr gleich häufig vertreten sind.
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y) # random_state zur Reproduzierbarkeit der Ergebnisse
mod = svm.SVC(kernel='linear')
pipe = make_pipeline(StandardScaler(), mod) # standardisieren, dann Modell anpassen
pipe.fit(X_train, y_train) # Pipeline auf Trainingsdaten anwenden
y_pred = pipe.predict(X_test)
pipe.score(X_test, y_test) # hierbei werden über die Pipeline auch die Testdaten separat standardisiert
0.5263157894736842
Die Vorhersage von Daten, die das Modell nicht gesehen hat, klappt hier also tatsächlich ein gutes Stück schlechter!
Da der Datensatz zufällig aufgeteilt wird, kann es passieren, dass wir eine besonders günstige oder ungünstige Aufteilung erwischen. Bei einer Wiederholung (mit anderem Wert von random_state oder ohne die Angabe dieses Arguments) käme ein anderer Wert heraus.
Bei der Kreuzvalidierung tun wir genau das: Wir teilen den Datensatz mehrmals neu auf, berechnen für jedes Modell ein Maß der Vorhersagequalität (oder mehrere) und betrachten dann die Ergebnisse. Wenn sie nah beieinander sind, ist unser Modell wahrscheinlich relativ robust. Wenn sie stärker streuen, sollten wir ihm dagegen weniger Vertrauen schenken ...
Es gibt verschiedene Methoden der Kreuzvalidierung. Sehr verbreitet ist k-fache Kreuzvalidierung, bei der der Datensatz in k möglichst gleich große Teile (folds) aufgeteilt wird. Trainingsdaten für ein einzelnes Modell sind k - 1 dieser Teile, der letzte Teil stellt die Testdaten dar. Insgesamt werden damit dann k Modelle erstellt.
In der Praxis hat sich 10-fache Kreuzvalidierung besonders bewährt. Weil unser Datensatz nicht allzu groß ist, führe ich in diesem Notebook aber nur 5-fache durch. (Gute Praxis ist auch, ganz zu Beginn einen Teil der Daten komplett zurückzuhalten, nur den Rest zur Modellbildung und Kreuzvalidierung zu verwenden und das letztlich ausgewählte Modell ganz zum Schluss an den zurückgehaltenen Daten auf die Probe zu stellen. Das sparen wir uns hier.)
cross_val_score(pipe, X, y, cv=5) # hier automatisch stratifiziert
array([0.47368421, 0.52631579, 0.39473684, 0.44736842, 0.48648649])
Schön an Scikit-learn ist, dass wir verschiedene statistische Methoden auf die gleiche Weise anwenden können. Es folgen einige Beispiele.
Ein naiver Bayes-Klassifikator wird gerne als Baseline (zum Vergleich verschiedener Modelle) verwendet:
gnb = GaussianNB() # https://scikit-learn.org/stable/modules/naive_bayes.html
pipe = make_pipeline(StandardScaler(), gnb)
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)
pipe.score(X_test, y_test)
0.42105263157894735
cross_val_score(pipe, X, y, cv=5)
array([0.42105263, 0.44736842, 0.44736842, 0.39473684, 0.37837838])
knn = KNeighborsClassifier(n_neighbors=6) # https://scikit-learn.org/stable/modules/generated/sklearn.neighbors.KNeighborsClassifier.html
pipe = make_pipeline(StandardScaler(), knn)
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)
pipe.score(X_test, y_test)
0.39473684210526316
cross_val_score(pipe, X, y, cv=5)
array([0.34210526, 0.39473684, 0.31578947, 0.47368421, 0.43243243])
Und logistische Regression (da logistische Regression eigentlich nur binär klassifizieren kann – entweder Klasse A oder Klasse B –, kombiniert Scikit-learn hier einfach mehrere logistische Regressionsmodelle):
lr = LogisticRegression(multi_class='multinomial') # https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html
pipe = make_pipeline(StandardScaler(), lr)
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)
pipe.score(X_test, y_test)
0.5526315789473685
cross_val_score(pipe, X, y, cv=5)
array([0.44736842, 0.5 , 0.42105263, 0.47368421, 0.45945946])
Basteln wir uns hier zuerst eine Tabelle mit den relativen Häufigkeiten jeder Wortart pro Text. (Vermutlich geht das auch effizienter, aber darum geht's hier gerade nicht.)
colnames = ['id', 'token', 'lemma', 'upos', 'xpos', 'feats', 'head', 'deprel', 'deps', 'misc'] # Spaltennamen für die annotierten Texte
ids = []
upos = []
tokens = []
with os.scandir(basedir + 'tagged-stanza/') as it:
for entry in it:
# für jede Datei im Verzeichnis:
if entry.name.endswith(".tsv") and entry.is_file():
# ID aus dem Dateinamen extrahieren:
textid = int(re.search(r'([0-9]+)(.tsv)', entry.name).group(1))
# Datei als DataFrame einlesen:
text = pd.read_table(entry.path, names=colnames, quoting=3)
# Anzahl der Tokens im Text an Liste "tokens" anhängen:
tokens.append(len(text))
# ID an Liste "ids" anhängen:
ids.append(textid)
# Series der Wortarten als Eintrag an Liste "upos" anhängen:
upos.append(text['upos'])
Wir könnten uns die Häufigkeiten alle selbst berechnen und daraus eine Tabelle basteln, müssten dabei aber darauf achten, dass manche Wortarten u.U. gar nicht in einem Text vorkommen (v.a. so etwas wie SYM oder X).
Wir sparen uns Arbeit, wenn wir auf die effiziente Funktion CountVectorizer() zurückgreifen, die Scikit-learn bereitstellt. Gedacht ist sie eigentlich für ganze Texte und möchte deshalb erst tokenisieren, aber wir können das mit einem eigenen Pseudotokenisierer (identity_tokenizer()) umgehen:
def identity_tokenizer(text):
return text
vectorizer = CountVectorizer(tokenizer=identity_tokenizer, lowercase=False)
upos = vectorizer.fit_transform(upos)
upos = pd.DataFrame(upos.toarray(),
columns=vectorizer.get_feature_names_out(),
index=ids)
upos
| ADJ | ADP | ADV | AUX | CCONJ | DET | INTJ | NOUN | NUM | PART | PRON | PROPN | PUNCT | SCONJ | SYM | VERB | X | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1006420 | 6792 | 9737 | 11659 | 8216 | 4168 | 8278 | 1076 | 13478 | 696 | 4071 | 19634 | 2750 | 21507 | 3007 | 25 | 15952 | 13 |
| 1016975 | 116 | 117 | 92 | 65 | 46 | 85 | 1 | 250 | 12 | 33 | 78 | 78 | 215 | 16 | 3 | 152 | 0 |
| 10290932 | 389 | 671 | 481 | 389 | 237 | 483 | 20 | 1080 | 16 | 268 | 1244 | 250 | 1201 | 138 | 0 | 1158 | 0 |
| 1042187 | 201 | 310 | 288 | 177 | 210 | 205 | 10 | 525 | 10 | 154 | 424 | 184 | 528 | 102 | 0 | 550 | 0 |
| 1042258 | 187 | 299 | 227 | 122 | 154 | 223 | 10 | 544 | 5 | 108 | 415 | 129 | 440 | 92 | 0 | 487 | 0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 9616364 | 385 | 611 | 461 | 356 | 182 | 403 | 48 | 930 | 16 | 260 | 1048 | 253 | 1169 | 141 | 0 | 1039 | 0 |
| 961984 | 1670 | 3435 | 2179 | 1850 | 1069 | 2631 | 93 | 5314 | 108 | 1167 | 5374 | 1768 | 6038 | 870 | 1 | 5283 | 0 |
| 964610 | 535 | 702 | 643 | 554 | 289 | 509 | 59 | 1174 | 36 | 285 | 836 | 482 | 1327 | 149 | 1 | 1046 | 0 |
| 9768917 | 360 | 675 | 389 | 360 | 195 | 475 | 32 | 1061 | 14 | 254 | 1011 | 290 | 1160 | 166 | 0 | 1051 | 0 |
| 984652 | 71 | 114 | 67 | 80 | 39 | 80 | 8 | 162 | 2 | 45 | 161 | 59 | 231 | 16 | 0 | 163 | 0 |
192 rows × 17 columns
Weil die Texte verschieden lang sind, können wir die absoluten Häufigkeiten schlecht direkt vergleichen. Also berechnen wir nun die relativen Häufigkeiten, indem wir die Werte in jeder Reihe (=> axis=0) durch die Zahl der Tokens im entsprechenden Text teilen (diese Zahlen haben wir uns vorhin in der Liste tokens gespeichert).
upos_rel = upos.divide(tokens, axis=0)
upos_rel
| ADJ | ADP | ADV | AUX | CCONJ | DET | INTJ | NOUN | NUM | PART | PRON | PROPN | PUNCT | SCONJ | SYM | VERB | X | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1006420 | 0.051824 | 0.074295 | 0.088960 | 0.062689 | 0.031802 | 0.063162 | 0.008210 | 0.102839 | 0.005311 | 0.031062 | 0.149810 | 0.020983 | 0.164102 | 0.022944 | 0.000191 | 0.121716 | 0.000099 |
| 1016975 | 0.085357 | 0.086093 | 0.067697 | 0.047829 | 0.033848 | 0.062546 | 0.000736 | 0.183959 | 0.008830 | 0.024283 | 0.057395 | 0.057395 | 0.158205 | 0.011773 | 0.002208 | 0.111847 | 0.000000 |
| 10290932 | 0.048474 | 0.083614 | 0.059938 | 0.048474 | 0.029533 | 0.060187 | 0.002492 | 0.134579 | 0.001994 | 0.033396 | 0.155016 | 0.031153 | 0.149657 | 0.017196 | 0.000000 | 0.144299 | 0.000000 |
| 1042187 | 0.051831 | 0.079938 | 0.074265 | 0.045642 | 0.054152 | 0.052862 | 0.002579 | 0.135379 | 0.002579 | 0.039711 | 0.109335 | 0.047447 | 0.136153 | 0.026302 | 0.000000 | 0.141826 | 0.000000 |
| 1042258 | 0.054329 | 0.086868 | 0.065950 | 0.035445 | 0.044741 | 0.064788 | 0.002905 | 0.158048 | 0.001453 | 0.031377 | 0.120569 | 0.037478 | 0.127833 | 0.026729 | 0.000000 | 0.141488 | 0.000000 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 9616364 | 0.052725 | 0.083676 | 0.063133 | 0.048754 | 0.024925 | 0.055190 | 0.006574 | 0.127362 | 0.002191 | 0.035607 | 0.143522 | 0.034648 | 0.160093 | 0.019310 | 0.000000 | 0.142290 | 0.000000 |
| 961984 | 0.042986 | 0.088417 | 0.056088 | 0.047619 | 0.027516 | 0.067722 | 0.002394 | 0.136782 | 0.002780 | 0.030039 | 0.138327 | 0.045508 | 0.155418 | 0.022394 | 0.000026 | 0.135985 | 0.000000 |
| 964610 | 0.062015 | 0.081372 | 0.074533 | 0.064217 | 0.033499 | 0.059001 | 0.006839 | 0.136084 | 0.004173 | 0.033036 | 0.096905 | 0.055871 | 0.153819 | 0.017271 | 0.000116 | 0.121247 | 0.000000 |
| 9768917 | 0.048045 | 0.090084 | 0.051915 | 0.048045 | 0.026024 | 0.063392 | 0.004271 | 0.141599 | 0.001868 | 0.033898 | 0.134926 | 0.038703 | 0.154811 | 0.022154 | 0.000000 | 0.140264 | 0.000000 |
| 984652 | 0.054700 | 0.087827 | 0.051618 | 0.061633 | 0.030046 | 0.061633 | 0.006163 | 0.124807 | 0.001541 | 0.034669 | 0.124037 | 0.045455 | 0.177966 | 0.012327 | 0.000000 | 0.125578 | 0.000000 |
192 rows × 17 columns
Jetzt verbinden wir diese Tabelle mit measures:
upos = upos.assign(id=ids)
measures_upos = measures.merge(upos, on='id')
Und wir können uns wieder ein Modell basteln:
y = measures_upos['author']
X = measures_upos.loc[:, "ADJ":"X"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
mod = svm.SVC(kernel='linear')
pipe = make_pipeline(StandardScaler(), mod)
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)
pipe.score(X_test, y_test)
0.23684210526315788
Fünffache Kreuzvalidierung:
cross_val_score(pipe, X, y, cv=5)
array([0.13157895, 0.15789474, 0.26315789, 0.10526316, 0.24324324])
Ein etwas unterwältigendes Ergebnis. Bringt es wenigstens etwas, wenn wir die neuen Spalten in Kombination mit den Maßen von vorhin verwenden?
y = measures_upos['author']
X = measures_upos.loc[:, "STTR_0250":"X"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
mod = svm.SVC(kernel='linear')
pipe = make_pipeline(StandardScaler(), mod)
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)
pipe.score(X_test, y_test)
0.5
cross_val_score(pipe, X, y, cv=5)
array([0.55263158, 0.52631579, 0.44736842, 0.42105263, 0.54054054])
Anscheinend nicht!
Wir haben bereits darüber gesprochen, dass Funktionswörter für die Stilometrie von besonderem Interesse sind, weil ihr Gebrauch vermutlich über Genres, Textsorten usw. hinweg relativ konstant ist (mit gewissen Einschränkungen – so kommen z.B. subordinierende Konjunktionen wahrscheinlich seltener in der mündlichen Sprache vor als in der schriftlichen, weil es dort auch weniger Nebensätze gibt).
Während Maße wie die obigen vielleicht besser geeignet sind, um Texte auf höheren Ebenen (z.B. Genre, Textsorte, Geschlecht oder Alter des Autors) voneinander zu unterscheiden, sollte es uns möglich sein, einzelne Autoren nach ihrem Gebrauch von Funktionswörtern relativ klar zu unterscheiden. (Alternativen zu Funktionswörtern: häufigste Wörter, N-Gramme oder Buchstaben-N-Gramme.)
ids = []
func = []
tokens = []
with os.scandir(basedir + 'tagged-stanza/') as it:
for entry in it:
if entry.name.endswith(".tsv") and entry.is_file():
textid = int(re.search(r'([0-9]+)(.tsv)', entry.name).group(1))
text = pd.read_table(entry.path, names=colnames, quoting=3)
tokens.append(len(text))
# text = text.query("upos != 'PUNCT'")
text['token'] = text['token'].str.replace("[‘’]", "'", regex=True) # normalisieren
text = text.query("upos in ['ADP', 'AUX', 'CCONJ', 'DET', 'PART', 'SCONJ']")
text = text['token'].str.lower() + '/' + text['upos']
ids.append(textid)
func.append(text)
vectorizer = CountVectorizer(tokenizer=identity_tokenizer, lowercase=False)
func = vectorizer.fit_transform(func)
func = pd.DataFrame(func.toarray(),
columns=vectorizer.get_feature_names_out(),
index=ids)
func
| !/ADP | &/CCONJ | '/AUX | '/PART | 'a/ADP | 'b/AUX | 'bout/ADP | 'bout/AUX | 'bout/DET | 'bout/SCONJ | ... | —so/SCONJ | —though/ADP | —to/ADP | —to/AUX | —to/PART | —was/AUX | —we/AUX | —”/ADP | “'/AUX | …/ADP | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1006420 | 0 | 0 | 0 | 2 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1016975 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 10290932 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1042187 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1042258 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 9616364 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 961984 | 0 | 0 | 0 | 6 | 0 | 0 | 2 | 0 | 0 | 1 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 964610 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 9768917 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 984652 | 0 | 0 | 0 | 3 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
192 rows × 725 columns
Wir sehen hier einige Tokenisierungs- und Taggingfehler (z.B. das Zeichen … als ADP). Das ist aber wahrscheinlich nicht weiter schlimm, weil wir uns gleich auf die häufigsten Funktionswörter beschränken werden (und diese fehlerhaften Token-Tag-Kombinationen sind zum Glück normalerweise selten).
Dazu berechnen wir nun wieder die relativen Häufigkeiten. Damit die Zahlen nicht zu viele Nullen nach dem Komma/Punkt haben, multiplizieren wir sie noch mit 1000, sodass wir für jedes Wort die Häufigkeit pro 1000 Wörter haben.
rel = func.divide(tokens, axis=0) * 1000
rel
| !/ADP | &/CCONJ | '/AUX | '/PART | 'a/ADP | 'b/AUX | 'bout/ADP | 'bout/AUX | 'bout/DET | 'bout/SCONJ | ... | —so/SCONJ | —though/ADP | —to/ADP | —to/AUX | —to/PART | —was/AUX | —we/AUX | —”/ADP | “'/AUX | …/ADP | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1006420 | 0.0 | 0.0 | 0.0 | 0.015260 | 0.0 | 0.0 | 0.00000 | 0.0 | 0.0 | 0.00000 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 1016975 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.0 | 0.0 | 0.00000 | 0.0 | 0.0 | 0.00000 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 10290932 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.0 | 0.0 | 0.00000 | 0.0 | 0.0 | 0.00000 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 1042187 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.0 | 0.0 | 0.00000 | 0.0 | 0.0 | 0.00000 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 1042258 | 0.0 | 0.0 | 0.0 | 0.290529 | 0.0 | 0.0 | 0.00000 | 0.0 | 0.0 | 0.00000 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 9616364 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.0 | 0.0 | 0.00000 | 0.0 | 0.0 | 0.00000 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 961984 | 0.0 | 0.0 | 0.0 | 0.154440 | 0.0 | 0.0 | 0.05148 | 0.0 | 0.0 | 0.02574 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 964610 | 0.0 | 0.0 | 0.0 | 0.115915 | 0.0 | 0.0 | 0.00000 | 0.0 | 0.0 | 0.00000 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 9768917 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.0 | 0.0 | 0.00000 | 0.0 | 0.0 | 0.00000 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 984652 | 0.0 | 0.0 | 0.0 | 2.311248 | 0.0 | 0.0 | 0.00000 | 0.0 | 0.0 | 0.00000 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
192 rows × 725 columns
Wir reduzieren diese Tabelle nun auf die 150 Funktionswörter, die insgesamt am häufigsten vorkommen:
# reduced = rel.iloc[:,((rel.max() > 1) & (rel.std() > .3)).to_list()] # Das ist der Versuch, nur Spalten auszuwählen, die sowohl in einzelnen Texten häufiger vorkommen als auch eine Mindestvarianz über die einzelnen Texte hinweg aufweisen. Müsste noch verfeinert werden.
# reduced = rel.iloc[:,((rel.max() > 1)).to_list()]
reduced = rel.reindex(rel.mean().sort_values(ascending=False).index, axis=1) # Spalten sortiert nach arithmetischem Mittel; alternativ (ausprobieren!): Median (median) oder Maximalwert (max)
reduced = reduced.iloc[:, 0:150] # die ersten 150 Spalten; ausprobieren, wie sich mehr oder weniger Spalten auf die nachfolgenden Modelle auswirken!
reduced
| the/DET | and/CCONJ | a/DET | of/ADP | to/PART | in/ADP | 's/PART | was/AUX | to/ADP | is/AUX | ... | na/PART | quite/DET | half/DET | among/ADP | outside/ADP | unless/SCONJ | get/AUX | below/ADP | ere/SCONJ | either/DET | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1006420 | 28.658848 | 21.723041 | 16.488757 | 14.077629 | 15.618920 | 9.728443 | 1.243715 | 10.735623 | 6.172792 | 4.578091 | ... | 0.190754 | 0.244165 | 0.091562 | 0.030521 | 0.038151 | 0.076302 | 0.076302 | 0.030521 | 0.0 | 0.015260 |
| 1016975 | 27.961737 | 25.754231 | 16.924209 | 20.603385 | 9.565857 | 16.188374 | 7.358352 | 5.150846 | 5.886681 | 10.301692 | ... | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.0 | 0.000000 |
| 10290932 | 36.137072 | 21.433022 | 15.950156 | 14.205607 | 15.077882 | 8.971963 | 6.978193 | 0.872274 | 9.844237 | 5.607477 | ... | 0.000000 | 0.373832 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.0 | 0.000000 |
| 1042187 | 29.396596 | 47.189273 | 11.861784 | 15.987622 | 17.792677 | 11.603920 | 14.182568 | 0.257865 | 4.383703 | 15.214028 | ... | 0.000000 | 0.000000 | 0.000000 | 0.515730 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.0 | 0.000000 |
| 1042258 | 37.478210 | 38.349797 | 15.979082 | 15.398024 | 15.688553 | 11.911679 | 11.330622 | 9.006392 | 5.229518 | 0.581058 | ... | 0.000000 | 0.000000 | 0.290529 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.0 | 0.000000 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 9616364 | 31.909066 | 19.720624 | 16.023007 | 13.147083 | 16.981649 | 8.490824 | 8.353876 | 1.780334 | 7.395234 | 6.025746 | ... | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.136949 | 0.000000 | 0.000000 | 0.000000 | 0.0 | 0.136949 |
| 961984 | 41.544402 | 19.742600 | 13.796654 | 18.893179 | 14.337194 | 10.090090 | 8.082368 | 8.391248 | 6.795367 | 2.213642 | ... | 0.000000 | 0.025740 | 0.000000 | 0.000000 | 0.000000 | 0.102960 | 0.000000 | 0.051480 | 0.0 | 0.025740 |
| 964610 | 34.079054 | 24.342182 | 14.605309 | 17.271357 | 14.025733 | 7.650400 | 11.011939 | 16.923612 | 5.216182 | 1.159152 | ... | 0.000000 | 0.115915 | 0.000000 | 0.000000 | 0.000000 | 0.463661 | 0.115915 | 0.231830 | 0.0 | 0.000000 |
| 9768917 | 37.635126 | 18.817563 | 16.548779 | 17.216068 | 16.682237 | 8.674763 | 8.674763 | 1.334579 | 10.810089 | 6.405979 | ... | 0.000000 | 0.266916 | 0.000000 | 0.000000 | 0.266916 | 0.133458 | 0.000000 | 0.000000 | 0.0 | 0.000000 |
| 984652 | 26.194145 | 21.571649 | 22.342065 | 8.474576 | 13.097072 | 11.556240 | 4.622496 | 16.178737 | 6.163328 | 0.770416 | ... | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.0 | 0.000000 |
192 rows × 150 columns
reduced = reduced.assign(id=ids)
new = measures.merge(reduced, on='id')
y = new['author']
X = new.loc[:, "the/DET":"either/DET"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y) # random_state zur Reproduzierbarkeit der Ergebnisse
mod = svm.SVC(kernel='linear')
pipe = make_pipeline(StandardScaler(), mod)
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)
pipe.score(X_test, y_test)
0.9210526315789473
Fünffache Kreuzvalidierung:
cross_val_score(pipe, X, y, cv=5)
array([0.84210526, 0.89473684, 0.97368421, 0.86842105, 0.89189189])
Das sieht doch schon viel besser aus!
Gucken wir uns mal in einer Konfusionsmatrix/Wahrheitsmatrix an, wie das bei einzelnen Autoren aussieht:
metrics.confusion_matrix(y_test, y_pred)
array([[4, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 4, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 2, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 4, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 2, 1, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 4, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 4, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 4, 0, 0],
[0, 0, 0, 0, 1, 0, 0, 0, 3, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 4]], dtype=int64)
Nicht sehr aussagekräftig? Dann machen wir doch eine Graphik daraus und fügen die Labels hinzu:
metrics.ConfusionMatrixDisplay.from_estimator(pipe, X_test, y_test, xticks_rotation='vertical')
plt.show()
Wir können uns das natürlich auch in Plotly bauen. Dafür brauchen wir zuerst die Labels als Liste.
y_names = pipe.classes_.tolist()
import plotly.figure_factory as ff
cm = metrics.confusion_matrix(y_test, y_pred)
fig = ff.create_annotated_heatmap(cm, x=y_names, y=y_names, annotation_text=cm, colorscale='Viridis')
fig['data'][0]['showscale'] = True
fig.update_layout(
autosize=False,
xaxis_title="Predicted label",
yaxis_title="True label",
)
fig.show()
Hier schauen wir uns noch an, ob der Gebrauch von Interpunktionszeichen Rückschlüsse auf den Autor eines Textes zulässt.
Da verschiedene typographische Zeichen z.T. dieselbe Funktion erfüllen können (z.B. “, ” und "), müssen wir uns überlegen, ob wir diese Zeichen normalisieren, also zusammenfassen wollen. Wenn wir das nicht tun, wird unser Modell möglicherweise sogar etwas besser, weil Autoren sich möglicherweise auch darin unterschieden, inwieweit sie bestimmten typographischen Konventionen folgen. Allerdings kann das mitunter auch von Text zu Text variieren, je nachdem, mit welchem Programm er zuerst verfasst wurde. Bei gedruckten Texten hängt es womöglich auch vom Verlag ab, welche Anführungszeichen bspw. verwendet werden – das wäre also kein Autorsignal und u.U. irreführend.
punct = []
with os.scandir(basedir + 'tagged-stanza/') as it:
for entry in it:
if entry.name.endswith(".tsv") and entry.is_file():
text = pd.read_table(entry.path, names=colnames, quoting=3)
text = text.query("upos == 'PUNCT'")
text = text['token']
# Normalisierung:
text = text.str.replace("[‘’]", "'", regex=True)
text = text.str.replace('[“”]', '"', regex=True)
text = text.str.replace('\.\.\.+', '…', regex=True)
text = text.str.replace('\?\?+', '??', regex=True)
text = text.str.replace('!!+', '!!', regex=True)
text = text.str.replace('\*\*+', '**', regex=True)
text = text.str.replace('---+', '---', regex=True)
text = text.str.replace('—', '–', regex=False)
punct.append(text)
vectorizer = CountVectorizer(tokenizer=identity_tokenizer, lowercase=False)
punct = vectorizer.fit_transform(punct)
punct = pd.DataFrame(punct.toarray(),
columns=vectorizer.get_feature_names_out(),
index=ids)
punct
| ! | !! | !!1 | !!11 | !!~ | !" | !' | !-- | !? | !?!? | ... | …>>>>>>>> | …? | …?" | …my | ……………………………… | ♡ | 🌟 | 😘 | 😞 | 😤 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1006420 | 596 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 5 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1016975 | 2 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 10290932 | 11 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1042187 | 4 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1042258 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 9616364 | 8 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 961984 | 32 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 964610 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 9768917 | 4 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 984652 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
192 rows × 313 columns
Wir sehen wieder, dass hier ein bisschen Blödsinn dabei ist. Berechnen wir also wieder relative Häufigkeiten und verkleinern die Tabelle dann.
punct_rel = punct.divide(tokens, axis=0) * 1000
punct_rel
| ! | !! | !!1 | !!11 | !!~ | !" | !' | !-- | !? | !?!? | ... | …>>>>>>>> | …? | …?" | …my | ……………………………… | ♡ | 🌟 | 😘 | 😞 | 😤 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1006420 | 4.547570 | 0.0 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.0 | 0.038151 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 1016975 | 1.471670 | 0.0 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 10290932 | 1.370717 | 0.0 | 0.0 | 0.0 | 0.0 | 0.124611 | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 1042187 | 1.031460 | 0.0 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 1042258 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 9616364 | 1.095590 | 0.0 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 961984 | 0.823681 | 0.0 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 964610 | 0.115915 | 0.0 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 9768917 | 0.533832 | 0.0 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 984652 | 0.770416 | 0.0 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.0 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
192 rows × 313 columns
punct_reduced = punct_rel.reindex(punct_rel.mean().sort_values(ascending=False).index, axis=1)
punct_reduced = punct_reduced.iloc[:, 0:29]
punct_reduced = punct_reduced.assign(id=ids)
punct_reduced
| , | . | " | ? | - | ! | ; | … | : | -- | ... | / | -" | --" | !! | …. | < | …? | ?! | ?? | id | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1006420 | 74.325304 | 43.041684 | 19.563708 | 8.522879 | 4.082131 | 4.547570 | 1.968579 | 4.982489 | 0.335727 | 0.000000 | ... | 0.045781 | 0.015260 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.038151 | 0.099192 | 0.0 | 1006420 |
| 1016975 | 64.753495 | 57.395143 | 0.000000 | 0.735835 | 7.358352 | 1.471670 | 1.471670 | 0.000000 | 10.301692 | 7.358352 | ... | 0.000000 | 0.000000 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 | 1016975 |
| 10290932 | 62.928349 | 51.588785 | 24.299065 | 5.109034 | 1.869159 | 1.370717 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | ... | 0.000000 | 0.000000 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 | 10290932 |
| 1042187 | 68.849923 | 28.880866 | 21.144920 | 0.773595 | 2.578649 | 1.031460 | 4.899433 | 1.547189 | 1.547189 | 3.867973 | ... | 0.000000 | 0.000000 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 | 1042187 |
| 1042258 | 63.916328 | 25.276002 | 16.850668 | 1.452644 | 4.938989 | 0.000000 | 5.520046 | 1.452644 | 2.324230 | 4.938989 | ... | 0.000000 | 0.000000 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 | 1042258 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 9616364 | 65.187620 | 43.960559 | 39.852095 | 4.382361 | 2.191180 | 1.095590 | 0.410846 | 0.000000 | 0.000000 | 0.000000 | ... | 0.000000 | 0.273898 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 | 9616364 |
| 961984 | 59.845560 | 46.151866 | 33.719434 | 3.912484 | 2.110682 | 0.823681 | 2.059202 | 0.643501 | 1.209781 | 0.000000 | ... | 0.000000 | 0.051480 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 | 961984 |
| 964610 | 60.275878 | 55.059696 | 22.371624 | 5.563927 | 1.390982 | 0.115915 | 1.159152 | 0.811406 | 1.043236 | 4.868436 | ... | 0.000000 | 0.000000 | 0.115915 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 | 964610 |
| 9768917 | 65.260910 | 47.244094 | 33.497931 | 2.802616 | 2.402242 | 0.533832 | 0.133458 | 0.266916 | 0.000000 | 0.000000 | ... | 0.000000 | 0.533832 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 | 9768917 |
| 984652 | 63.944530 | 64.714946 | 41.602465 | 0.770416 | 4.622496 | 0.770416 | 0.770416 | 0.000000 | 0.770416 | 0.000000 | ... | 0.000000 | 0.000000 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 | 984652 |
192 rows × 30 columns
new2 = measures.merge(punct_reduced, on='id')
y = new2['author']
X = new2.loc[:, ",":"??"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y) # random_state zur Reproduzierbarkeit der Ergebnisse
mod = svm.SVC(kernel='linear')
pipe = make_pipeline(StandardScaler(), mod)
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)
pipe.score(X_test, y_test)
0.7368421052631579
Fünffache Kreuzvalidierung:
cross_val_score(pipe, X, y, cv=5)
array([0.55263158, 0.81578947, 0.73684211, 0.73684211, 0.78378378])
Gar nicht so schlecht für ein paar Satzzeichen!
Gucken wir mal, was die Kombination mit den sonstigen Merkmalen bringt:
reduced_combined = reduced.merge(punct_reduced, on='id')
reduced_combined
| the/DET | and/CCONJ | a/DET | of/ADP | to/PART | in/ADP | 's/PART | was/AUX | to/ADP | is/AUX | ... | [ | / | -" | --" | !! | …. | < | …? | ?! | ?? | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 28.658848 | 21.723041 | 16.488757 | 14.077629 | 15.618920 | 9.728443 | 1.243715 | 10.735623 | 6.172792 | 4.578091 | ... | 0.015260 | 0.045781 | 0.015260 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.038151 | 0.099192 | 0.0 |
| 1 | 27.961737 | 25.754231 | 16.924209 | 20.603385 | 9.565857 | 16.188374 | 7.358352 | 5.150846 | 5.886681 | 10.301692 | ... | 2.207506 | 0.000000 | 0.000000 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 |
| 2 | 36.137072 | 21.433022 | 15.950156 | 14.205607 | 15.077882 | 8.971963 | 6.978193 | 0.872274 | 9.844237 | 5.607477 | ... | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 |
| 3 | 29.396596 | 47.189273 | 11.861784 | 15.987622 | 17.792677 | 11.603920 | 14.182568 | 0.257865 | 4.383703 | 15.214028 | ... | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 |
| 4 | 37.478210 | 38.349797 | 15.979082 | 15.398024 | 15.688553 | 11.911679 | 11.330622 | 9.006392 | 5.229518 | 0.581058 | ... | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 187 | 31.909066 | 19.720624 | 16.023007 | 13.147083 | 16.981649 | 8.490824 | 8.353876 | 1.780334 | 7.395234 | 6.025746 | ... | 0.000000 | 0.000000 | 0.273898 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 |
| 188 | 41.544402 | 19.742600 | 13.796654 | 18.893179 | 14.337194 | 10.090090 | 8.082368 | 8.391248 | 6.795367 | 2.213642 | ... | 0.000000 | 0.000000 | 0.051480 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 |
| 189 | 34.079054 | 24.342182 | 14.605309 | 17.271357 | 14.025733 | 7.650400 | 11.011939 | 16.923612 | 5.216182 | 1.159152 | ... | 0.000000 | 0.000000 | 0.000000 | 0.115915 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 |
| 190 | 37.635126 | 18.817563 | 16.548779 | 17.216068 | 16.682237 | 8.674763 | 8.674763 | 1.334579 | 10.810089 | 6.405979 | ... | 0.000000 | 0.000000 | 0.533832 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 |
| 191 | 26.194145 | 21.571649 | 22.342065 | 8.474576 | 13.097072 | 11.556240 | 4.622496 | 16.178737 | 6.163328 | 0.770416 | ... | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.0 | 0.0 | 0.0 | 0.000000 | 0.000000 | 0.0 |
192 rows × 180 columns
new3 = measures.merge(reduced_combined, on='id')
y = new3['author']
X = new3.loc[:, "the/DET":"??"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y) # random_state zur Reproduzierbarkeit der Ergebnisse
mod = svm.SVC(kernel='linear')
pipe = make_pipeline(StandardScaler(), mod)
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)
pipe.score(X_test, y_test)
0.9210526315789473
Fünffache Kreuzvalidierung:
cross_val_score(pipe, X, y, cv=5)
array([0.84210526, 0.92105263, 0.94736842, 0.89473684, 0.94594595])
Das sieht tatsächlich noch ein bisschen besser aus, die Satzzeichen scheinen also nützlich zu sein. Mit logistischer Regression kommen wir zu einem ähnlichen Ergebnis:
lr = LogisticRegression(multi_class='multinomial') # https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html
pipe = make_pipeline(StandardScaler(), lr)
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)
pipe.score(X_test, y_test)
0.9473684210526315
cross_val_score(pipe, X, y, cv=5)
array([0.86842105, 0.92105263, 0.94736842, 0.86842105, 0.94594595])
y_names = pipe.classes_.tolist()
import plotly.figure_factory as ff
cm = metrics.confusion_matrix(y_test, y_pred)
fig = ff.create_annotated_heatmap(cm, x=y_names, y=y_names, annotation_text=cm, colorscale='Viridis')
fig['data'][0]['showscale'] = True
fig.update_layout(
autosize=False,
xaxis_title="Predicted label",
yaxis_title="True label",
)
fig.show()
Neben der Modellgenauigkeit, die wir uns bisher angesehen haben (accuracy) gibt es noch andere Maßzahlen für Klassifikatoren (siehe auch den deutschen und englischen Wikipediaartikel dazu):
y_test viermal vor, allerdings wurden davon nur drei Texte korrekt klassifiziert (ein Text wurde fälschlicherweise Eastmava zugeordnet). Auch hier lässt sich wieder ein Makrowert berechnen.Gewichtete Makropräzision:
metrics.precision_score(y_test, y_pred, average='weighted')
0.9614035087719298
Gewichteter Recall:
metrics.recall_score(y_test, y_pred, average='weighted')
0.9473684210526315
Gewichtetes F1-Maß:
metrics.f1_score(y_test, y_pred, average='weighted')
0.9477025898078529
Auf welches Maß man am stärksten achten sollte, hängt von der konkreten Klassifikationsaufgabe ab.
Bei einem Corona-Test ist es z.B. wahrscheinlich nicht allzu dramatisch, wenn ein positives Testergebnis fälschlich zustandekommt (die eigentlich gesunde Person wird dann eben in Quarantäne geschickt, wird nachuntersucht usw.). Das heißt, man möchte hier vielleicht nicht um jeden Preis die Präzision optimieren. Dagegen ist die Sensitivität (der Recall) sehr wichtig: Wenn jemand krank ist, soll der Test das auch feststellen.
Bei einem Spamfilter ist hingegen die Präzision etwas wichtiger. Wenn etwas als Spam erkannt wird, dann soll es auch wirklich Spam sein (denn sonst landet womöglich eine wichtige Mail ungelesen im Spam-Ordner).